feat(stream): add an activity heatmap that drives the date filter - #2839
Open
karlitschek wants to merge 6 commits into
Open
feat(stream): add an activity heatmap that drives the date filter#2839karlitschek wants to merge 6 commits into
karlitschek wants to merge 6 commits into
Conversation
The date range added with the search filters was hidden behind a dropdown, so
choosing one meant guessing which period had anything in it. A "Heatmap" entry
in the app navigation now opens an overview drawing a year of activity as a
calendar grid, and picking cells chooses a period: click a day, or shift-click
a second one for a span. Clicking the only selected day deselects it, so the
same cell toggles rather than trapping the reader in a one day view. "Show
these activities" then opens the stream restricted to that period, carrying
the range as query parameters because the stream already restores its filters
from the URL, which also makes the result linkable.
It is a view of its own rather than a band above the feed: a chart that is
permanently in the way of the list people came to read is a distraction, and
this one answers a different question from the stream it feeds.
Counts come from a new endpoint,
`GET /api/v2/activity/{filter}/histogram`, returning activities per calendar
day.
It shares its WHERE building with the stream query. That refactor is the
point rather than tidiness: a histogram whose columns count rows the feed
below it does not list is worse than no histogram, and the filter, type, app
and favourites conditions are far too involved to keep correct in two
places. The endpoint honours the active search and account for the same
reason, but deliberately takes no from/to, because the histogram is the
control a range is picked *with* and has to keep showing the days outside
the selection.
Days are bucketed in the account's timezone rather than UTC, so an activity
at 01:00 local time belongs to that local day. A real DateTimeZone is passed
rather than a fixed offset, which is what keeps a window spanning a DST
change correct. Bucketing runs in PHP: grouping by day needs integer
division or a modulo, neither expressible through IQueryBuilder, and a raw
expression would need four dialects for the databases this app tests
against. The query stays cheap regardless — one column, one affecteduser, a
timestamp range, which is the existing activity_user_time index.
Hitting the row guard is reported as `partial_before` instead of hidden, so
the affected days are marked rather than drawn as measured but understated
values.
On the colour: the ramp is the theme's own accent mixed into the surface at
45/63/81/100% in OKLab, so it follows a custom primary colour, and because
--color-primary-element is contrast-adjusted per theme by the server the
scale runs light-to-dark on white and dark-to-light on the dark surface —
the anchor flip a sequential scale needs, without a second palette. The four
steps were checked against both Nextcloud surfaces: monotone OKLCH
lightness, adjacent gaps >= 0.09, single hue. The lightest step was raised
until it clears 2:1 against the surface, because the palest tint is a day
with *one* activity and had been indistinguishable from an empty one; "no
activity" is a neutral instead, which reads as nothing rather than as a
small value.
Days with no activity are omitted from the payload rather than sent as
zeroes, so its size tracks real activity instead of the window length, and
the client fills the gaps.
The grid is a real table with a caption and row headers, every cell is a
button carrying the date and exact count as its accessible name, and focus
shows the same readout as hover — so no value depends on either hovering or
reading a colour. One cell is exposed to Tab at a time, with arrow keys
moving a day vertically and a week horizontally, and Home/End jumping to the
ends of the window. Selection is drawn as a ring, never a fill, since the
fill is spoken for by the value and repainting it would misstate the count.
Rendered in both themes and inspected before shipping, which is what caught
the window being too short: at 26 weeks the grid filled under half the column
and left the legend stranded, so it defaults to 52.
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds a dedicated “Heatmap” view to explore activity density over time and use it to drive the stream’s date-range filter, backed by a new API histogram endpoint that shares query conditions with the stream to keep counts consistent.
Changes:
- Add
GET /api/v2/activity/{filter}/histogramendpoint returning per-day counts plusmax,total, and truncation marker. - Introduce heatmap UI (grid/table + keyboard navigation + selection) and a new navigation entry + route.
- Refactor stream query condition building to be shared with histogram counting; add backend + frontend unit tests.
Reviewed changes
Copilot reviewed 20 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DataTest.php | Adds integration-style tests for Data::getDailyCounts() bucketing and window behavior. |
| tests/Controller/APIv2ControllerTest.php | Adds controller tests for histogram response shape, clamping, and parameter forwarding. |
| src/views/ActivityHeatmapView.vue | New view wrapping the heatmap and translating selection into stream URL query params. |
| src/views/ActivityAppNavigation.vue | Adds “Heatmap” entry to app navigation. |
| src/utils/heatmap.ts | Heatmap domain helpers (binning, window fill, week grouping, selection). |
| src/routes.ts | Registers /heatmap route before the filter catch-all. |
| src/components/ActivityHeatmap.vue | New heatmap grid component with loading, accessibility, selection, and keyboard support. |
| src/tests/heatmap.test.ts | Unit tests for heatmap utilities. |
| src/tests/ActivityHeatmapView.test.ts | Unit tests for the view’s navigation/query behavior. |
| src/tests/ActivityHeatmap.test.ts | Component tests for loading/rendering/selection/keyboard behavior. |
| lib/Data.php | Extracts shared stream conditions and adds getDailyCounts() histogram bucketing + truncation marker. |
| lib/Controller/APIv2Controller.php | Adds getHistogram() endpoint and wires in viewer timezone. |
| js/translation-DoG5ZELJ-B36BKsSA.chunk.mjs.license | Generated license metadata update for rebuilt JS bundles. |
| js/settings-store-Qqd8_iJD.chunk.mjs | Removes previous generated settings-store chunk (rebundle). |
| js/settings-store-DQkngcMb.chunk.mjs.license | Generated license metadata for new settings-store chunk. |
| js/settings-store-DQkngcMb.chunk.mjs | New generated settings-store chunk (rebundle). |
| js/NcCheckboxRadioSwitch-BVTMQSAg-OXnfnqVj.chunk.mjs.license | Generated license metadata update for rebuilt JS bundles. |
| js/index-Dh8mfCs2.chunk.mjs.license | Generated license metadata update for rebuilt JS bundles. |
| js/index-DCC5Jy0q.chunk.mjs.license | Generated license metadata update for rebuilt JS bundles. |
| js/ActivityTab-Dpi1imUR.chunk.mjs.map | Generated sourcemap update for rebuilt JS bundle. |
| js/ActivityTab-Dpi1imUR.chunk.mjs.license | Generated license metadata update for rebuilt JS bundle. |
| js/ActivityTab-Dpi1imUR.chunk.mjs | New generated ActivityTab chunk (rebundle). |
| js/ActivityTab-Cxq6JF8r.chunk.mjs | Removes previous generated ActivityTab chunk (rebundle). |
| js/ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs.license | Generated license metadata update for rebuilt JS bundle. |
| js/activity-personalSettings.mjs | Updates generated imports/chunk references after rebundle. |
| js/activity-adminSettings.mjs | Updates generated imports/chunk references after rebundle. |
| docs/endpoint-v2.md | Documents the new histogram endpoint and response fields. |
| appinfo/routes.php | Registers the histogram route ahead of the catch-all filter route. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+468
to
+472
| if ($rows > self::MAX_HISTOGRAM_ROWS && $oldestCounted !== null) { | ||
| // The day the guard cut off is only partly counted, so drop it and | ||
| // tell the client where the data stops being trustworthy instead of | ||
| // drawing a bar that understates the day | ||
| unset($counts[$oldestCounted]); |
php-cs-fixer wants a multi-line @return description aligned under the description column, which for this signature meant indenting the wrap to column 68. The description belongs in the docblock body instead, so the @return line stays a single type and there is nothing to align. Reproduced and verified with php-cs-fixer 3.95.15, the version CI pins: 1 of 97 files before, 0 of 97 after. Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The date range added with the search filters was hidden behind a dropdown, so choosing one meant guessing which period had anything in it. A "Heatmap" entry in the app navigation now opens an overview drawing a year of activity as a calendar grid, and picking cells chooses a period: click a day, or shift-click a second one for a span. Clicking the only selected day deselects it, so the same cell toggles rather than trapping the reader in a one day view. "Show these activities" then opens the stream restricted to that period, carrying the range as query parameters because the stream already restores its filters from the URL, which also makes the result linkable.
It is a view of its own rather than a band above the feed: a chart that is permanently in the way of the list people came to read is a distraction, and this one answers a different question from the stream it feeds.
Counts come from a new endpoint,
GET /api/v2/activity/{filter}/histogram, returning activities per calendar day.It shares its WHERE building with the stream query. That refactor is the point rather than tidiness: a histogram whose columns count rows the feed below it does not list is worse than no histogram, and the filter, type, app and favourites conditions are far too involved to keep correct in two places. The endpoint honours the active search and account for the same reason, but deliberately takes no from/to, because the histogram is the control a range is picked with and has to keep showing the days outside the selection.
Days are bucketed in the account's timezone rather than UTC, so an activity at 01:00 local time belongs to that local day. A real DateTimeZone is passed rather than a fixed offset, which is what keeps a window spanning a DST change correct. Bucketing runs in PHP: grouping by day needs integer division or a modulo, neither expressible through IQueryBuilder, and a raw expression would need four dialects for the databases this app tests against. The query stays cheap regardless — one column, one affecteduser, a timestamp range, which is the existing activity_user_time index.
Hitting the row guard is reported as
partial_beforeinstead of hidden, so the affected days are marked rather than drawn as measured but understated values.On the colour: the ramp is the theme's own accent mixed into the surface at 45/63/81/100% in OKLab, so it follows a custom primary colour, and because --color-primary-element is contrast-adjusted per theme by the server the scale runs light-to-dark on white and dark-to-light on the dark surface — the anchor flip a sequential scale needs, without a second palette. The four steps were checked against both Nextcloud surfaces: monotone OKLCH lightness, adjacent gaps >= 0.09, single hue. The lightest step was raised until it clears 2:1 against the surface, because the palest tint is a day with one activity and had been indistinguishable from an empty one; "no activity" is a neutral instead, which reads as nothing rather than as a small value.
Days with no activity are omitted from the payload rather than sent as zeroes, so its size tracks real activity instead of the window length, and the client fills the gaps.
The grid is a real table with a caption and row headers, every cell is a button carrying the date and exact count as its accessible name, and focus shows the same readout as hover — so no value depends on either hovering or reading a colour. One cell is exposed to Tab at a time, with arrow keys moving a day vertically and a week horizontally, and Home/End jumping to the ends of the window. Selection is drawn as a ring, never a fill, since the fill is spoken for by the value and repainting it would misstate the count.
Rendered in both themes and inspected before shipping, which is what caught the window being too short: at 26 weeks the grid filled under half the column and left the legend stranded, so it defaults to 52.
🤖 AI (if applicable)